1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
import ImageIO
import UIKit
enum ImageProcessing {
/// Resize `image` to fit within `maxDimension` and compress it to at most
/// `maxBytes`, returning the JPEG and the pixel size it was encoded at.
///
/// Quality is given up before resolution, since softening is less visible
/// than shrinking. When even the lowest quality at a given size is still
/// too big, the image is scaled down and the search runs again — and again,
/// because one pass is not enough for a photograph JPEG struggles with.
///
/// Staying under the ceiling matters: the PDS rejects an oversized blob
/// only after the upload has already been paid for, so an image that goes
/// over costs the user the whole transfer and then fails the post.
static func resizeImage(_ image: UIImage, maxDimension: CGFloat, maxBytes: Int) -> (Data, CGSize) {
let pixelWidth = image.size.width * image.scale
let pixelHeight = image.size.height * image.scale
let scaleFactor = min(maxDimension / pixelWidth, maxDimension / pixelHeight, 1)
var size = CGSize(width: round(pixelWidth * scaleFactor), height: round(pixelHeight * scaleFactor))
var best = Data()
var bestSize = size
while true {
let scaled = redraw(image, at: size)
let attempt = compress(scaled, maxBytes: maxBytes)
best = attempt.data
bestSize = size
if attempt.fitsCeiling {
break
}
// Halve the pixel count and try again. The floor is there so a
// ceiling nothing could meet ends in a small image rather than an
// endless loop.
let next = CGSize(
width: round(size.width * Self.downscaleStep),
height: round(size.height * Self.downscaleStep)
)
guard min(next.width, next.height) >= Self.minDimension else { break }
size = next
}
return (best, bestSize)
}
/// Scales the pixel count by half each pass.
private static let downscaleStep: CGFloat = 0.7071
/// Below this a photo is too small to be worth posting, so an unreachable
/// ceiling stops here rather than degrading to a thumbnail.
private static let minDimension: CGFloat = 240
/// JPEG's own floor — below this the artefacts cost more than the bytes save.
private static let minQuality: CGFloat = 0.01
private static func redraw(_ image: UIImage, at size: CGSize) -> UIImage {
let format = UIGraphicsImageRendererFormat()
format.scale = 1
return UIGraphicsImageRenderer(size: size, format: format).image { _ in
image.draw(in: CGRect(origin: .zero, size: size))
}
}
/// The highest-quality encoding of `image` that fits in `maxBytes`.
///
/// `fitsCeiling` is false when even `minQuality` is too big, in which case
/// the data returned is the smallest this size can produce — the caller's
/// cue to scale down rather than to ship it.
private static func compress(_ image: UIImage, maxBytes: Int) -> (data: Data, fitsCeiling: Bool) {
let smallest = image.jpegData(compressionQuality: minQuality) ?? Data()
guard smallest.count <= maxBytes else { return (smallest, false) }
var best = smallest
var low = minQuality
var high: CGFloat = 1
for _ in 0 ..< 10 {
let mid = (low + high) / 2
guard let data = image.jpegData(compressionQuality: mid) else { break }
if data.count <= maxBytes {
best = data
low = mid
} else {
high = mid
}
}
return (best, true)
}
/// Extract GPS coordinates from image data. Returns (latitude, longitude) or nil.
static func extractGPS(from data: Data) -> (latitude: Double, longitude: Double)? {
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any],
let gpsDict = properties[kCGImagePropertyGPSDictionary as String] as? [String: Any]
else {
return nil
}
guard let latitude = gpsDict[kCGImagePropertyGPSLatitude as String] as? Double,
let latRef = gpsDict[kCGImagePropertyGPSLatitudeRef as String] as? String,
let longitude = gpsDict[kCGImagePropertyGPSLongitude as String] as? Double,
let lonRef = gpsDict[kCGImagePropertyGPSLongitudeRef as String] as? String
else {
return nil
}
let lat = latRef == "S" ? -latitude : latitude
let lon = lonRef == "W" ? -longitude : longitude
return (lat, lon)
}
}