Skip to content

Commit 7ee29b1

Browse files
committed
first commit
0 parents  commit 7ee29b1

19 files changed

Lines changed: 961 additions & 0 deletions

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.DS_Store
2+
/.build
3+
/Packages
4+
/*.xcodeproj
5+
.swiftpm
6+
Package.resolved
7+
.claude

.swiftformat

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
--indent 2
2+
--indentcase true
3+
--patternlet inline
4+
--disable unusedArguments
5+
--disable redundantReturn
6+
--disable opaqueGenericParameters
7+
--disable hoistTry
8+
--disable blankLinesBetweenChainedFunctions

Package.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// swift-tools-version:5.10
2+
3+
import PackageDescription
4+
5+
let package = Package(
6+
name: "Scry",
7+
products: [
8+
.library(name: "Scry", targets: ["Scry"]),
9+
],
10+
targets: [
11+
.target(name: "Scry"),
12+
.testTarget(
13+
name: "ScryTests",
14+
dependencies: ["Scry"],
15+
resources: [.copy("Fixtures")]
16+
),
17+
]
18+
)

README.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# Scry
2+
3+
A pure Swift EXIF metadata parser. No dependencies, no Apple frameworks. Fully macOS and Linux compatible.
4+
5+
Supports **JPEG**, **PNG**, **WebP**, and **TIFF**.
6+
7+
## Usage
8+
9+
```swift
10+
import Scry
11+
12+
// From a file path
13+
if let metadata = try Scry.metadata(fromFileAt: "/path/to/photo.jpg") {
14+
print(metadata["Make"]) // "Apple"
15+
print(metadata["Model"]) // "iPhone 14 Pro"
16+
print(metadata["FNumber"]) // 1.78
17+
}
18+
19+
// From Data
20+
let data = Data(contentsOf: url)
21+
if let metadata = try Scry.metadata(from: data) {
22+
// format is auto-detected
23+
}
24+
```
25+
26+
Returns `nil` when the image has no EXIF data or the format is unsupported.
27+
28+
## Installation
29+
30+
Add to your `Package.swift`:
31+
32+
```swift
33+
dependencies: [
34+
.package(url: "https://github.com/loopwerk/Scry.git", from: "1.0.0"),
35+
]
36+
```
37+
38+
## Metadata properties
39+
40+
`Scry.metadata` returns a flat `[String: Any]` dictionary. The keys and value types are:
41+
42+
### Image info (from TIFF IFD0)
43+
44+
| Key | Type | Example |
45+
|-----|------|---------|
46+
| `ImageWidth` | `Int` | `4032` |
47+
| `ImageLength` | `Int` | `3024` |
48+
| `ImageDescription` | `String` | `"Sunset photo"` |
49+
| `Make` | `String` | `"Apple"` |
50+
| `Model` | `String` | `"iPhone 14 Pro"` |
51+
| `Orientation` | `Int` | `1` |
52+
| `XResolution` | `Double` | `72.0` |
53+
| `YResolution` | `Double` | `72.0` |
54+
| `ResolutionUnit` | `Int` | `2` |
55+
| `Software` | `String` | `"18.7.3"` |
56+
| `DateTime` | `String` | `"2026:01:05 17:11:42"` |
57+
| `Artist` | `String` | `"Kevin Renskers"` |
58+
| `HostComputer` | `String` | `"iPhone 14 Pro"` |
59+
| `Copyright` | `String` | `"2026 Kevin Renskers"` |
60+
61+
### Exposure and camera (from EXIF sub-IFD)
62+
63+
| Key | Type | Example |
64+
|-----|------|---------|
65+
| `ExposureTime` | `Double` | `0.04` (seconds) |
66+
| `FNumber` | `Double` | `1.78` |
67+
| `ExposureProgram` | `Int` | `2` |
68+
| `ISOSpeedRatings` | `Int` | `800` |
69+
| `ExifVersion` | `[UInt8]` | `[2, 3, 2]` |
70+
| `DateTimeOriginal` | `String` | `"2026:01:05 17:11:42"` |
71+
| `DateTimeDigitized` | `String` | `"2026:01:05 17:11:42"` |
72+
| `ShutterSpeedValue` | `Double` | `4.64` (APEX) |
73+
| `ApertureValue` | `Double` | `1.66` (APEX) |
74+
| `BrightnessValue` | `Double` | `-2.50` (APEX) |
75+
| `ExposureBiasValue` | `Double` | `0.0` (EV) |
76+
| `MeteringMode` | `Int` | `5` |
77+
| `Flash` | `Int` | `16` |
78+
| `FocalLength` | `Double` | `6.86` (mm) |
79+
| `OffsetTime` | `String` | `"+01:00"` |
80+
| `OffsetTimeOriginal` | `String` | `"+01:00"` |
81+
| `OffsetTimeDigitized` | `String` | `"+01:00"` |
82+
| `PixelXDimension` | `Int` | `4032` |
83+
| `PixelYDimension` | `Int` | `3024` |
84+
| `CustomRendered` | `Int` | `0` |
85+
| `ExposureMode` | `Int` | `0` |
86+
| `WhiteBalance` | `Int` | `0` |
87+
| `FocalLenIn35mmFilm` | `Int` | `24` |
88+
| `SceneCaptureType` | `Int` | `0` |
89+
| `BodySerialNumber` | `String` | `"DNXXXXXXXXXXXX"` |
90+
| `LensSpecification` | `[Double]` | `[2.22, 9.0, 1.78, 2.8]` |
91+
| `LensMake` | `String` | `"Apple"` |
92+
| `LensModel` | `String` | `"iPhone 14 Pro back triple camera 6.86mm f/1.78"` |
93+
| `LensSerialNumber` | `String` | `"XXXXXXXXXX"` |
94+
95+
### GPS (from GPS sub-IFD)
96+
97+
| Key | Type | Example |
98+
|-----|------|---------|
99+
| `GPSVersionID` | `[Int]` | `[2, 3, 0, 0]` |
100+
| `GPSLatitudeRef` | `String` | `"N"` |
101+
| `GPSLatitude` | `[Double]` | `[52.0, 31.0, 12.74]` (deg, min, sec) |
102+
| `GPSLongitudeRef` | `String` | `"E"` |
103+
| `GPSLongitude` | `[Double]` | `[13.0, 24.0, 36.33]` (deg, min, sec) |
104+
| `GPSAltitudeRef` | `Int` | `0` (0 = above sea level) |
105+
| `GPSAltitude` | `Double` | `38.39` (meters) |
106+
| `GPSTimeStamp` | `[Double]` | `[16.0, 11.0, 42.0]` (hrs, min, sec) |
107+
| `GPSSpeedRef` | `String` | `"K"` (km/h) |
108+
| `GPSSpeed` | `Double` | `0.0` |
109+
| `GPSImgDirectionRef` | `String` | `"T"` (true north) |
110+
| `GPSImgDirection` | `Double` | `139.56` (degrees) |
111+
| `GPSDestBearingRef` | `String` | `"T"` |
112+
| `GPSDestBearing` | `Double` | `139.56` (degrees) |
113+
| `GPSDateStamp` | `String` | `"2026:01:05"` |
114+
115+
Only keys present in the image are included in the dictionary. Not every image will have every property.

Sources/Scry/ByteReader.swift

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import Foundation
2+
3+
struct ByteReader {
4+
enum ByteOrder {
5+
case big, little
6+
}
7+
8+
let data: Data
9+
var offset: Int
10+
var byteOrder: ByteOrder
11+
12+
init(data: Data, offset: Int = 0, byteOrder: ByteOrder = .big) {
13+
self.data = data
14+
self.offset = offset
15+
self.byteOrder = byteOrder
16+
}
17+
18+
var remaining: Int {
19+
max(0, data.count - offset)
20+
}
21+
22+
mutating func readUInt8() throws -> UInt8 {
23+
guard offset < data.count else { throw EXIFError.truncatedData }
24+
let value = data[data.startIndex + offset]
25+
offset += 1
26+
return value
27+
}
28+
29+
mutating func readUInt16() throws -> UInt16 {
30+
guard offset + 2 <= data.count else { throw EXIFError.truncatedData }
31+
let start = data.startIndex + offset
32+
let raw = UInt16(data[start]) << 8 | UInt16(data[start + 1])
33+
offset += 2
34+
switch byteOrder {
35+
case .big: return raw
36+
case .little: return raw.byteSwapped
37+
}
38+
}
39+
40+
mutating func readUInt32() throws -> UInt32 {
41+
guard offset + 4 <= data.count else { throw EXIFError.truncatedData }
42+
let start = data.startIndex + offset
43+
let raw = UInt32(data[start]) << 24 | UInt32(data[start + 1]) << 16
44+
| UInt32(data[start + 2]) << 8 | UInt32(data[start + 3])
45+
offset += 4
46+
switch byteOrder {
47+
case .big: return raw
48+
case .little: return raw.byteSwapped
49+
}
50+
}
51+
52+
mutating func readInt32() throws -> Int32 {
53+
Int32(bitPattern: try readUInt32())
54+
}
55+
56+
mutating func readBytes(_ count: Int) throws -> Data {
57+
guard offset + count <= data.count else { throw EXIFError.truncatedData }
58+
let start = data.startIndex + offset
59+
let result = data[start ..< start + count]
60+
offset += count
61+
return Data(result)
62+
}
63+
64+
mutating func readASCII(_ count: Int) throws -> String {
65+
let bytes = try readBytes(count)
66+
// Strip null terminators and trailing whitespace
67+
let trimmed = bytes.prefix(while: { $0 != 0 })
68+
return String(data: Data(trimmed), encoding: .utf8)
69+
?? String(data: Data(trimmed), encoding: .ascii)
70+
?? ""
71+
}
72+
73+
mutating func skip(_ count: Int) throws {
74+
guard offset + count <= data.count else { throw EXIFError.truncatedData }
75+
offset += count
76+
}
77+
78+
/// Create a reader at a specific offset within the same data, inheriting byte order.
79+
func readerAt(_ offset: Int) -> ByteReader {
80+
ByteReader(data: data, offset: offset, byteOrder: byteOrder)
81+
}
82+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import Foundation
2+
3+
enum JPEGContainer {
4+
/// Extract EXIF TIFF data from a JPEG file.
5+
static func extractEXIF(from data: Data) throws -> Data {
6+
guard data.count >= 4,
7+
data[data.startIndex] == 0xFF,
8+
data[data.startIndex + 1] == 0xD8
9+
else {
10+
throw EXIFError.invalidJPEGStructure
11+
}
12+
13+
var offset = 2
14+
15+
while offset + 4 <= data.count {
16+
let start = data.startIndex + offset
17+
18+
// Each marker starts with 0xFF
19+
guard data[start] == 0xFF else {
20+
throw EXIFError.invalidJPEGStructure
21+
}
22+
23+
let marker = data[start + 1]
24+
25+
// Skip padding bytes (0xFF 0xFF...)
26+
if marker == 0xFF {
27+
offset += 1
28+
continue
29+
}
30+
31+
// SOS (Start of Scan) or EOI — no more metadata segments
32+
if marker == 0xDA || marker == 0xD9 {
33+
break
34+
}
35+
36+
// Markers without a length field (RST, TEM, SOI)
37+
if marker == 0x00 || marker == 0x01 || (0xD0 ... 0xD7).contains(marker) {
38+
offset += 2
39+
continue
40+
}
41+
42+
// Read segment length (includes the 2 length bytes but not the marker)
43+
guard offset + 4 <= data.count else { break }
44+
let length = Int(data[start + 2]) << 8 | Int(data[start + 3])
45+
guard length >= 2 else { throw EXIFError.invalidJPEGStructure }
46+
47+
// APP1 marker (0xE1) — check for EXIF signature
48+
if marker == 0xE1 {
49+
let payloadStart = offset + 4
50+
let payloadLength = length - 2
51+
52+
if payloadLength >= 6, payloadStart + 6 <= data.count {
53+
let sigStart = data.startIndex + payloadStart
54+
// "Exif\0\0"
55+
if data[sigStart] == 0x45, data[sigStart + 1] == 0x78,
56+
data[sigStart + 2] == 0x69, data[sigStart + 3] == 0x66,
57+
data[sigStart + 4] == 0x00, data[sigStart + 5] == 0x00
58+
{
59+
let tiffStart = payloadStart + 6
60+
let tiffLength = payloadLength - 6
61+
guard tiffStart + tiffLength <= data.count else { throw EXIFError.truncatedData }
62+
return Data(data[data.startIndex + tiffStart ..< data.startIndex + tiffStart + tiffLength])
63+
}
64+
}
65+
}
66+
67+
// Move to next marker
68+
offset += 2 + length
69+
}
70+
71+
throw EXIFError.noEXIFData
72+
}
73+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import Foundation
2+
3+
enum PNGContainer {
4+
/// Extract EXIF TIFF data from a PNG file's eXIf chunk.
5+
static func extractEXIF(from data: Data) throws -> Data {
6+
// PNG signature: 89 50 4E 47 0D 0A 1A 0A
7+
guard data.count >= 8,
8+
data[data.startIndex] == 0x89,
9+
data[data.startIndex + 1] == 0x50,
10+
data[data.startIndex + 2] == 0x4E,
11+
data[data.startIndex + 3] == 0x47
12+
else {
13+
throw EXIFError.invalidPNGStructure
14+
}
15+
16+
var offset = 8 // skip signature
17+
18+
// Iterate through chunks: [4 bytes length][4 bytes type][data][4 bytes CRC]
19+
while offset + 8 <= data.count {
20+
let start = data.startIndex + offset
21+
22+
// Chunk data length (big-endian)
23+
let length = Int(data[start]) << 24 | Int(data[start + 1]) << 16
24+
| Int(data[start + 2]) << 8 | Int(data[start + 3])
25+
26+
// Chunk type (4 ASCII bytes)
27+
let typeStart = start + 4
28+
guard typeStart + 4 <= data.endIndex else { break }
29+
let chunkType = String(data: data[typeStart ..< typeStart + 4], encoding: .ascii) ?? ""
30+
31+
let dataStart = offset + 8
32+
guard dataStart + length + 4 <= data.count else { break }
33+
34+
if chunkType == "eXIf" {
35+
return Data(data[data.startIndex + dataStart ..< data.startIndex + dataStart + length])
36+
}
37+
38+
// IEND — end of PNG
39+
if chunkType == "IEND" { break }
40+
41+
// Move to next chunk: length field (4) + type (4) + data + CRC (4)
42+
offset += 12 + length
43+
}
44+
45+
throw EXIFError.noEXIFData
46+
}
47+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import Foundation
2+
3+
enum TIFFContainer {
4+
/// For TIFF files, the entire file is the TIFF structure — pass through.
5+
static func extractEXIF(from data: Data) throws -> Data {
6+
guard data.count >= 8,
7+
(data[data.startIndex] == 0x49 && data[data.startIndex + 1] == 0x49)
8+
|| (data[data.startIndex] == 0x4D && data[data.startIndex + 1] == 0x4D)
9+
else {
10+
throw EXIFError.invalidTIFFHeader
11+
}
12+
return data
13+
}
14+
}

0 commit comments

Comments
 (0)