Skip to content

Commit aed7225

Browse files
committed
imaging: rewrite EXIF facade against SwiftExif 0.1.0 typed API
Drop the ExifSnapshot adapter and the "Keywords"/coercion plumbing. SwiftExif.Image.parse(at:) returns a Sendable ExifResult with typed IptcFields, which is exactly what the adapter was hand-rolling. Photo+Read switches from snapshot.iptcStrings[...] to iptc.city / provinceState / countryCode / countryName, and from .iptcKeywords to iptc.keywords. The .exif and .exifRaw dicts keep the same shape, so per-IFD/per-tag reads are untouched. readExif stays non-throws; ParseError.fileUnreadable is swallowed to an empty result because the VIPS probe on the same path will surface that condition with a richer error.
1 parent f3ce0ea commit aed7225

2 files changed

Lines changed: 24 additions & 56 deletions

File tree

Sources/MuninKit/IO/Imaging/EXIF.swift

Lines changed: 16 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -3,56 +3,24 @@ import SwiftExif
33
import SystemPackage
44

55
/// EXIF + IPTC reads on the same `Imaging` facade as the VIPS calls.
6-
/// Direct `SwiftExif.Image` use lives only in this file so the
7-
/// non-`Sendable` struct stays contained; callers only ever see the
8-
/// Sendable `ExifSnapshot` value type.
6+
/// `SwiftExif.Image.parse(at:)` already returns a Sendable `ExifResult`,
7+
/// so the facade is a thin wrapper — its only job is to keep the
8+
/// SwiftExif import contained inside `IO/Imaging/` (enforced by
9+
/// `ImagingFacadeTests`).
910
extension Imaging {
1011

11-
/// Read EXIF + IPTC in one pass. Errors from SwiftExif surface as
12-
/// empty sub-dictionaries, matching today's "no EXIF / no IPTC is
13-
/// fine" behaviour — a source without a proper EXIF block is still a
14-
/// valid photo, just one with fewer populated `Photo` fields.
15-
static func readExif(source: FilePath) -> ExifSnapshot {
16-
let image = SwiftExif.Image(imagePath: URL(fileURLWithPath: source.string))
17-
18-
var iptcStrings: [String: String] = [:]
19-
var iptcKeywords: [String] = []
20-
for (key, value) in image.Iptc() {
21-
if let str = value as? String {
22-
iptcStrings[key] = str
23-
} else if key == iptcKeywordsKey, let list = value as? [String] {
24-
iptcKeywords = list
25-
}
12+
/// Read EXIF + IPTC in one pass. `SwiftExif.parse(at:)` only throws
13+
/// `ParseError.fileUnreadable`, which in Munin's flow is racing the
14+
/// directory walker — the VIPS probe on the same path will surface
15+
/// the same condition with a richer error, so we swallow it here
16+
/// and return an empty result. A source without a proper EXIF block
17+
/// is still a valid photo, just one with fewer populated `Photo`
18+
/// fields.
19+
static func readExif(source: FilePath) -> ExifResult {
20+
do {
21+
return try SwiftExif.Image.parse(at: URL(fileURLWithPath: source.string))
22+
} catch {
23+
return ExifResult(exif: [:], exifRaw: [:], iptc: IptcFields(), orientation: nil)
2624
}
27-
28-
return ExifSnapshot(
29-
exif: image.Exif(),
30-
exifRaw: image.ExifRaw(),
31-
iptcStrings: iptcStrings,
32-
iptcKeywords: iptcKeywords)
3325
}
34-
35-
/// SwiftExif's key for the IPTC "Keywords" field.
36-
private static let iptcKeywordsKey = "Keywords"
37-
}
38-
39-
/// Bundled EXIF + IPTC read from a single source. Sendable value type;
40-
/// can cross task boundaries without any unsafe escape hatches.
41-
struct ExifSnapshot: Sendable {
42-
/// Human-readable EXIF values, keyed `[ifd][tag]`. Matches
43-
/// `SwiftExif.Image.Exif()`.
44-
let exif: [String: [String: String]]
45-
46-
/// Raw EXIF values, keyed `[ifd][tag]`. Matches
47-
/// `SwiftExif.Image.ExifRaw()`.
48-
let exifRaw: [String: [String: String]]
49-
50-
/// IPTC fields whose values are plain strings — City, Province/State,
51-
/// Country Code, Country Name, etc. Pre-separated from `iptcKeywords`
52-
/// so the caller doesn't have to do `as? String` / `as? [String]`
53-
/// coercions on `Any`.
54-
let iptcStrings: [String: String]
55-
56-
/// IPTC "Keywords" field, normalised to an array. Empty when absent.
57-
let iptcKeywords: [String]
5826
}

Sources/MuninKit/Photo+Read.swift

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,9 @@ func readPhotoFromPath(
9393
let dateFormatter = DateFormatter()
9494
dateFormatter.dateFormat = "yyyy:MM:dd HH:mm:ss"
9595

96-
let exifSnapshot = Imaging.readExif(source: filePath)
97-
let exifDict = exifSnapshot.exif
98-
let exifRawDict = exifSnapshot.exifRaw
96+
let exifResult = Imaging.readExif(source: filePath)
97+
let exifDict = exifResult.exif
98+
let exifRawDict = exifResult.exifRaw
9999

100100
var photo = Photo(
101101
name: name,
@@ -220,10 +220,10 @@ func readPhotoFromPath(
220220
}
221221

222222
// Add location data if available
223-
if let city = exifSnapshot.iptcStrings["City"],
224-
let state = exifSnapshot.iptcStrings["Province/State"],
225-
let locationCode = exifSnapshot.iptcStrings["Country Code"],
226-
let locationName = exifSnapshot.iptcStrings["Country Name"]
223+
if let city = exifResult.iptc.city,
224+
let state = exifResult.iptc.provinceState,
225+
let locationCode = exifResult.iptc.countryCode,
226+
let locationName = exifResult.iptc.countryName
227227
{
228228
photo.location = LocationData(
229229
city: city,
@@ -250,7 +250,7 @@ func readPhotoFromPath(
250250
photo.keywords.append(locationNameKeyword)
251251
}
252252

253-
for keyword in exifSnapshot.iptcKeywords {
253+
for keyword in exifResult.iptc.keywords {
254254
let keywordPointer = KeywordPointer(
255255
name: keyword,
256256
url: "\(ctx.config.outputPath)/keywords/\(urlifyName(keyword)).json"

0 commit comments

Comments
 (0)