Skip to content

Commit ccfe51c

Browse files
committed
feat: Do no load the entire image into memory just to read EXIF data
1 parent c825d15 commit ccfe51c

13 files changed

Lines changed: 203 additions & 188 deletions

File tree

.github/FUNDING.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
github: loopwerk
2+
buy_me_a_coffee: loopwerk

.github/workflows/release.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: Create Release
2+
3+
on:
4+
push:
5+
tags:
6+
- "*"
7+
8+
jobs:
9+
create-release:
10+
runs-on: ubuntu-latest
11+
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v6
15+
16+
- name: Create changelog text
17+
id: changelog
18+
uses: loopwerk/tag-changelog@v1
19+
with:
20+
token: ${{ secrets.GITHUB_TOKEN }}
21+
include_commit_body: true
22+
exclude_types: other,doc,chore,build
23+
24+
- name: Create release
25+
uses: softprops/action-gh-release@v2
26+
with:
27+
body: ${{ steps.changelog.outputs.changes }}
28+
token: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/test.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: Test
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
branches:
9+
- "*"
10+
11+
jobs:
12+
test-xcode:
13+
runs-on: macos-latest
14+
15+
steps:
16+
- uses: actions/checkout@v6
17+
- uses: maxim-lobanov/setup-xcode@v1
18+
with:
19+
xcode-version: latest-stable
20+
- name: Build
21+
run: swift build -v
22+
- name: Run tests
23+
run: swift test -v
24+
25+
test-linux:
26+
runs-on: ubuntu-latest
27+
steps:
28+
- uses: actions/checkout@v6
29+
- name: Build
30+
run: swift build -v
31+
- name: Run tests
32+
run: swift test -v

README.md

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,23 @@
22

33
A pure Swift EXIF metadata parser. No dependencies, no Apple frameworks. Fully macOS and Linux compatible.
44

5-
Supports **JPEG**, **PNG**, **WebP**, and **TIFF**.
5+
Supports **JPEG**, **PNG**, and **WebP** images.
6+
7+
Scry does not load the entire image into memory, and as such can be used with large image files.
68

79
## Usage
810

911
```swift
1012
import Scry
1113

12-
// From a file path
1314
if let metadata = try Scry.metadata(fromFileAt: "/path/to/photo.jpg") {
1415
print(metadata["Make"]) // "Apple"
1516
print(metadata["Model"]) // "iPhone 14 Pro"
1617
print(metadata["FNumber"]) // 1.78
1718
}
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-
}
2419
```
2520

26-
Returns `nil` when the image has no EXIF data or the format is unsupported.
21+
Returns `nil` when the image has no EXIF data.
2722

2823
## Installation
2924

Sources/Scry/Containers/JPEGContainer.swift

Lines changed: 39 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,66 @@
11
import Foundation
22

33
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 {
4+
/// Extract EXIF TIFF data from a JPEG file handle, reading only metadata segments.
5+
static func extractEXIF(from fileHandle: FileHandle) throws -> Data {
6+
let soi = fileHandle.readData(ofLength: 2)
7+
guard soi.count == 2, soi[0] == 0xFF, soi[1] == 0xD8 else {
108
throw EXIFError.invalidJPEGStructure
119
}
1210

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 {
11+
while true {
12+
// Read marker (2 bytes)
13+
let markerData = fileHandle.readData(ofLength: 2)
14+
guard markerData.count == 2, markerData[0] == 0xFF else {
2015
throw EXIFError.invalidJPEGStructure
2116
}
2217

23-
let marker = data[start + 1]
18+
let marker = markerData[1]
2419

25-
// Skip padding bytes (0xFF 0xFF...)
20+
// Skip padding bytes
2621
if marker == 0xFF {
27-
offset += 1
22+
fileHandle.seek(toFileOffset: fileHandle.offsetInFile - 1)
2823
continue
2924
}
3025

31-
// SOS (Start of Scan) or EOI — no more metadata segments
32-
if marker == 0xDA || marker == 0xD9 {
33-
break
34-
}
26+
// SOS or EOI — no more metadata segments
27+
if marker == 0xDA || marker == 0xD9 { break }
3528

36-
// Markers without a length field (RST, TEM, SOI)
29+
// Markers without a length field
3730
if marker == 0x00 || marker == 0x01 || (0xD0 ... 0xD7).contains(marker) {
38-
offset += 2
3931
continue
4032
}
4133

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])
34+
// Read segment length
35+
let lengthData = fileHandle.readData(ofLength: 2)
36+
guard lengthData.count == 2 else { break }
37+
let length = Int(lengthData[0]) << 8 | Int(lengthData[1])
4538
guard length >= 2 else { throw EXIFError.invalidJPEGStructure }
4639

47-
// APP1 marker (0xE1) — check for EXIF signature
48-
if marker == 0xE1 {
49-
let payloadStart = offset + 4
50-
let payloadLength = length - 2
40+
let payloadLength = length - 2
5141

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-
}
42+
// APP1 marker — check for EXIF signature
43+
if marker == 0xE1, payloadLength >= 6 {
44+
let sig = fileHandle.readData(ofLength: 6)
45+
guard sig.count == 6 else { throw EXIFError.truncatedData }
46+
// "Exif\0\0"
47+
if sig[0] == 0x45, sig[1] == 0x78, sig[2] == 0x69, sig[3] == 0x66,
48+
sig[4] == 0x00, sig[5] == 0x00
49+
{
50+
let tiffLength = payloadLength - 6
51+
let tiffData = fileHandle.readData(ofLength: tiffLength)
52+
guard tiffData.count == tiffLength else { throw EXIFError.truncatedData }
53+
return tiffData
6454
}
55+
// Not EXIF APP1, skip the rest of this segment
56+
let remaining = payloadLength - 6
57+
if remaining > 0 {
58+
fileHandle.seek(toFileOffset: fileHandle.offsetInFile + UInt64(remaining))
59+
}
60+
} else {
61+
// Skip segment payload
62+
fileHandle.seek(toFileOffset: fileHandle.offsetInFile + UInt64(payloadLength))
6563
}
66-
67-
// Move to next marker
68-
offset += 2 + length
6964
}
7065

7166
throw EXIFError.noEXIFData

Sources/Scry/Containers/PNGContainer.swift

Lines changed: 16 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,32 @@
11
import Foundation
22

33
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 {
4+
/// Extract EXIF TIFF data from a PNG file handle, reading only chunk headers until eXIf is found.
5+
static func extractEXIF(from fileHandle: FileHandle) throws -> Data {
6+
let sig = fileHandle.readData(ofLength: 8)
7+
guard sig.count == 8, sig[0] == 0x89, sig[1] == 0x50, sig[2] == 0x4E, sig[3] == 0x47 else {
138
throw EXIFError.invalidPNGStructure
149
}
1510

16-
var offset = 8 // skip signature
11+
while true {
12+
// Read chunk header: 4 bytes length + 4 bytes type
13+
let header = fileHandle.readData(ofLength: 8)
14+
guard header.count == 8 else { break }
1715

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 }
16+
let length = Int(header[0]) << 24 | Int(header[1]) << 16
17+
| Int(header[2]) << 8 | Int(header[3])
18+
let chunkType = String(data: header[4 ..< 8], encoding: .ascii) ?? ""
3319

3420
if chunkType == "eXIf" {
35-
return Data(data[data.startIndex + dataStart ..< data.startIndex + dataStart + length])
21+
let chunkData = fileHandle.readData(ofLength: length)
22+
guard chunkData.count == length else { throw EXIFError.truncatedData }
23+
return chunkData
3624
}
3725

38-
// IEND — end of PNG
3926
if chunkType == "IEND" { break }
4027

41-
// Move to next chunk: length field (4) + type (4) + data + CRC (4)
42-
offset += 12 + length
28+
// Skip chunk data + 4 bytes CRC
29+
fileHandle.seek(toFileOffset: fileHandle.offsetInFile + UInt64(length + 4))
4330
}
4431

4532
throw EXIFError.noEXIFData

Sources/Scry/Containers/TIFFContainer.swift

Lines changed: 0 additions & 14 deletions
This file was deleted.

Sources/Scry/Containers/WebPContainer.swift

Lines changed: 26 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,46 @@
11
import Foundation
22

33
enum WebPContainer {
4-
/// Extract EXIF TIFF data from a WebP file's EXIF chunk.
5-
static func extractEXIF(from data: Data) throws -> Data {
6-
// RIFF header: "RIFF" + 4 bytes size + "WEBP"
7-
guard data.count >= 12,
8-
data[data.startIndex] == 0x52, // R
9-
data[data.startIndex + 1] == 0x49, // I
10-
data[data.startIndex + 2] == 0x46, // F
11-
data[data.startIndex + 3] == 0x46, // F
12-
data[data.startIndex + 8] == 0x57, // W
13-
data[data.startIndex + 9] == 0x45, // E
14-
data[data.startIndex + 10] == 0x42, // B
15-
data[data.startIndex + 11] == 0x50 // P
4+
/// Extract EXIF TIFF data from a WebP file handle, reading only chunk headers until EXIF is found.
5+
static func extractEXIF(from fileHandle: FileHandle) throws -> Data {
6+
let header = fileHandle.readData(ofLength: 12)
7+
guard header.count == 12,
8+
header[0] == 0x52, header[1] == 0x49, header[2] == 0x46, header[3] == 0x46,
9+
header[8] == 0x57, header[9] == 0x45, header[10] == 0x42, header[11] == 0x50
1610
else {
1711
throw EXIFError.invalidWebPStructure
1812
}
1913

20-
var offset = 12 // after "RIFF" + size + "WEBP"
14+
while true {
15+
// Read chunk header: 4 bytes FourCC + 4 bytes size (little-endian)
16+
let chunkHeader = fileHandle.readData(ofLength: 8)
17+
guard chunkHeader.count == 8 else { break }
2118

22-
// Scan RIFF chunks: [4 bytes FourCC][4 bytes size (little-endian)][data][padding to even]
23-
while offset + 8 <= data.count {
24-
let start = data.startIndex + offset
25-
let fourCC = String(data: data[start ..< start + 4], encoding: .ascii) ?? ""
26-
27-
// Size is little-endian
28-
let size = Int(data[start + 4])
29-
| Int(data[start + 5]) << 8
30-
| Int(data[start + 6]) << 16
31-
| Int(data[start + 7]) << 24
32-
33-
let dataStart = offset + 8
34-
guard dataStart + size <= data.count else { break }
19+
let fourCC = String(data: chunkHeader[0 ..< 4], encoding: .ascii) ?? ""
20+
let size = Int(chunkHeader[4])
21+
| Int(chunkHeader[5]) << 8
22+
| Int(chunkHeader[6]) << 16
23+
| Int(chunkHeader[7]) << 24
3524

3625
if fourCC == "EXIF" {
37-
var exifStart = dataStart
38-
var exifSize = size
26+
var chunkData = fileHandle.readData(ofLength: size)
27+
guard chunkData.count == size else { throw EXIFError.truncatedData }
3928

4029
// Some encoders prepend "Exif\0\0" like JPEG; strip if present
41-
if exifSize >= 6 {
42-
let s = data.startIndex + exifStart
43-
if data[s] == 0x45, data[s + 1] == 0x78,
44-
data[s + 2] == 0x69, data[s + 3] == 0x66,
45-
data[s + 4] == 0x00, data[s + 5] == 0x00
46-
{
47-
exifStart += 6
48-
exifSize -= 6
49-
}
30+
if chunkData.count >= 6,
31+
chunkData[0] == 0x45, chunkData[1] == 0x78,
32+
chunkData[2] == 0x69, chunkData[3] == 0x66,
33+
chunkData[4] == 0x00, chunkData[5] == 0x00
34+
{
35+
chunkData = Data(chunkData.dropFirst(6))
5036
}
5137

52-
return Data(data[data.startIndex + exifStart ..< data.startIndex + exifStart + exifSize])
38+
return chunkData
5339
}
5440

5541
// RIFF chunks are padded to even byte boundaries
56-
offset = dataStart + size + (size % 2)
42+
let paddedSize = size + (size % 2)
43+
fileHandle.seek(toFileOffset: fileHandle.offsetInFile + UInt64(paddedSize))
5744
}
5845

5946
throw EXIFError.noEXIFData

Sources/Scry/EXIFError.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,18 @@ public enum EXIFError: Error, CustomStringConvertible {
88
case invalidTIFFHeader
99
case noEXIFData
1010
case truncatedData
11+
case fileNotFound
1112

1213
public var description: String {
1314
switch self {
1415
case .unsupportedFormat: "Unsupported image format"
1516
case .invalidJPEGStructure: "Invalid JPEG structure"
1617
case .invalidPNGStructure: "Invalid PNG structure"
1718
case .invalidWebPStructure: "Invalid WebP structure"
18-
case .invalidTIFFHeader: "Invalid TIFF header"
19+
case .invalidTIFFHeader: "Invalid TIFF header in EXIF data"
1920
case .noEXIFData: "No EXIF data found"
2021
case .truncatedData: "Unexpected end of data"
22+
case .fileNotFound: "File not found"
2123
}
2224
}
2325
}

0 commit comments

Comments
 (0)