Skip to content

Commit 43a7280

Browse files
committed
fix(ios): correct crop rect size, orientation, and renderer scale
Four related bugs in iOS caused Image.crop() / Image.resize() (and, by extension, Image.rotate() slow path and ImageFactory.createBlankImage) to produce incorrect output. They surface most visibly through the vision-camera v5 flow Photo.toImageAsync() -> crop -> resize -> saveToTemporaryFileAsync(), but repro from any source image. ios/Public/NativeImage.swift: 1. crop: the targetRect used CGSize(uiImage.size.width, uiImage.size.height) instead of the computed (targetWidth, targetHeight). CGImage.cropping intersects the rect with the image bounds, so callers got "image minus origin offset" instead of an actual crop of the requested region. 2. crop: the pre-crop UIImage could have non-.up imageOrientation and/or scale != 1 (both common for the Photo.toImageAsync() bridge, which constructs UIImage(cgImage:, scale: 1, orientation: uiOrientation)). uiImage.size is display-space while cgImage is sensor-space, so the caller's rect landed in the wrong region of cgImage. UIImage(cgImage:) also defaulted the output to .up orientation, dropping the source's orientation and producing sensor-oriented pixels. Fix: if orientation != .up or scale != 1, draw through a scale=1 UIGraphicsImageRenderer to bake both into pixels before cropping, then wrap the cropped CGImage with explicit scale=1 and .up so downstream consumers see consistent state. 3. resize + rotate (slow path): UIGraphicsImageRenderer(size:) without a UIGraphicsImageRendererFormat defaults to UIScreen.main.scale. On 2x/3x devices "resize to 2000x1500" produced 4000x3000 or 6000x4500-pixel output, while uiImage.size still reported (2000, 1500) in points. JS callers saw correct Image.width/height but the saved JPEG was 2-3x too large in each dimension. Fix: set UIGraphicsImageRendererFormat().scale = 1 before constructing the renderer. ios/HybridImageFactory.swift: 4. createBlankImage had the same default-scale issue; applied the same scale = 1 fix. Android (HybridImage.kt) is unchanged -- its Bitmap-based path has no point/pixel distinction and was already correct. Verified on an iPhone 15 Pro (3x screen scale) via a vision-camera v5 photo-scanning app. Before: cropped region was wrong, orientation dropped to landscape sensor-space, saved files 3x larger than requested. After: crop produces the requested region in portrait orientation, and saved files match the requested pixel dimensions.
1 parent 80e8088 commit 43a7280

2 files changed

Lines changed: 51 additions & 8 deletions

File tree

packages/react-native-nitro-image/ios/HybridImageFactory.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,14 @@ class HybridImageFactory: HybridImageFactorySpec {
1414
* Create new blank Image
1515
*/
1616
func createBlankImage(width: Double, height: Double, enableAlpha: Bool, fill: Color?) throws -> any HybridImageSpec {
17-
// 1. Prepare image config
17+
// 1. Prepare image config. Force scale=1 so pixel dims == (width, height);
18+
// otherwise UIGraphicsImageRendererFormat() defaults to UIScreen.main.scale
19+
// and a "create blank 100x100" call produces a 300x300-pixel bitmap on 3x
20+
// devices while uiImage.size still reads (100, 100) in points.
1821
let size = CGSize(width: width, height: height)
1922
let format = UIGraphicsImageRendererFormat()
2023
format.opaque = !enableAlpha
24+
format.scale = 1
2125
// 2. Create a new UIImage
2226
let uiImage = UIGraphicsImageRenderer(size: size, format: format).image { canvas in
2327
if let fill {

packages/react-native-nitro-image/ios/Public/NativeImage.swift

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,13 @@ public extension NativeImage {
6262
let rotated = UIImage(cgImage: cgImage, scale: uiImage.scale, orientation: newOrientation)
6363
return HybridImage(uiImage: rotated)
6464
} else {
65-
// Slow path: we actually rotate using UIGraphicsImageRenderer
66-
let renderer = UIGraphicsImageRenderer(size: uiImage.size)
65+
// Slow path: we actually rotate using UIGraphicsImageRenderer.
66+
// Force scale=1 so output pixel dims match uiImage.size. Without a format,
67+
// UIGraphicsImageRenderer defaults to UIScreen.main.scale (2x/3x on modern
68+
// iPhones), producing a pixel-inflated bitmap.
69+
let format = UIGraphicsImageRendererFormat()
70+
format.scale = 1
71+
let renderer = UIGraphicsImageRenderer(size: uiImage.size, format: format)
6772
let rotatedImage = renderer.image { context in
6873
let width = uiImage.size.width
6974
let height = uiImage.size.height
@@ -97,7 +102,15 @@ public extension NativeImage {
97102
}
98103
let targetSize = CGSize(width: width, height: height)
99104

100-
let renderer = UIGraphicsImageRenderer(size: targetSize)
105+
// Force scale=1 so output pixel dims == targetSize. Without a format,
106+
// UIGraphicsImageRenderer defaults to UIScreen.main.scale, producing a
107+
// bitmap of (width * scale, height * scale) pixels on 2x/3x devices while
108+
// uiImage.size still reports (width, height) in points. The encoded JPEG
109+
// then has the inflated pixel dimensions, which is surprising from JS
110+
// where Image.width/Image.height looked correct.
111+
let format = UIGraphicsImageRendererFormat()
112+
format.scale = 1
113+
let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
101114
let resizedImage = renderer.image { context in
102115
let targetRect = CGRect(origin: .zero, size: targetSize)
103116
uiImage.draw(in: targetRect)
@@ -120,16 +133,42 @@ public extension NativeImage {
120133
guard targetHeight > 0 else {
121134
throw RuntimeError.error(withMessage: "Height cannot be less than 0! (startY: \(startY) - endY: \(endY) = \(targetHeight))")
122135
}
123-
guard let cgImage = uiImage.cgImage else {
136+
137+
// Normalize the UIImage before cropping. Two reasons:
138+
// 1. When imageOrientation != .up (common for e.g. vision-camera's
139+
// Photo.toImageAsync() output which uses UIImage(cgImage:, orientation:)),
140+
// uiImage.size is in DISPLAY space but uiImage.cgImage is in SENSOR space.
141+
// Our (startX, startY, endX, endY) rect is in display space, so calling
142+
// cgImage.cropping(to:) directly crops the wrong region.
143+
// 2. When uiImage.scale != 1, cgImage pixel dims are (size * scale), which
144+
// makes the rect mean something different from what the caller expects.
145+
// Drawing through a scale=1 renderer bakes orientation into pixels and
146+
// normalizes scale so cgImage and our rect agree.
147+
let normalized: UIImage
148+
if uiImage.imageOrientation == .up && uiImage.scale == 1 {
149+
normalized = uiImage
150+
} else {
151+
let format = UIGraphicsImageRendererFormat()
152+
format.scale = 1
153+
let renderer = UIGraphicsImageRenderer(size: uiImage.size, format: format)
154+
normalized = renderer.image { _ in
155+
uiImage.draw(at: .zero)
156+
}
157+
}
158+
guard let cgImage = normalized.cgImage else {
124159
throw RuntimeError.error(withMessage: "This image does not have an underlying .cgImage!")
125160
}
126161

127-
let targetRect = CGRect(origin: CGPoint(x: startX, y: startY),
128-
size: CGSize(width: uiImage.size.width, height: uiImage.size.height))
162+
// Use the actual crop size (targetWidth, targetHeight). Previously this was
163+
// CGSize(uiImage.size.width, uiImage.size.height), which made the rect the
164+
// whole image and produced "image minus origin offset" instead of a real crop.
165+
let targetRect = CGRect(x: startX, y: startY, width: targetWidth, height: targetHeight)
129166
guard let croppedCgImage = cgImage.cropping(to: targetRect) else {
130167
throw RuntimeError.error(withMessage: "Failed to crop CGImage to \(targetRect)!")
131168
}
132-
let croppedUiImage = UIImage(cgImage: croppedCgImage)
169+
// Wrap with explicit scale=1 and .up orientation so downstream ops see
170+
// consistent state instead of inheriting UIImage defaults that drop metadata.
171+
let croppedUiImage = UIImage(cgImage: croppedCgImage, scale: 1, orientation: .up)
133172
return HybridImage(uiImage: croppedUiImage)
134173
}
135174

0 commit comments

Comments
 (0)